C++ 虚函数与虚继承内存模型:vtable、Thunk 与 VTT
p->func() 这样的虚调用,在编译产物里到底发生了什么?菱形虚继承为什么会让对象里出现多个虚表指针?这些问题在面试和读汇编时反复出现,答案都落在 vtable 的具体布局上。本文以 GCC/Clang 系编译器共同遵循的 Itanium C++ ABI 为基准,从单继承讲到多继承、虚继承,解释 vptr、offset_to_top、Thunk、VTT 这些实体的用途。C++ 标准不规定内存布局,以下内容是主流类 Unix 平台的实际实现。
vptr 与 vtable:表格驱动的分派
多态类型(声明或继承了至少一个虚函数的类型)的对象里,头部存放一个虚表指针(vptr),指向该类型的虚函数表(vtable)。虚函数的地址按声明顺序排列在表中,基类与派生类对同一虚函数使用相同的槽位偏移:
struct A {
int ax;
virtual void f0();
virtual void f1();
};
struct B : A {
int bx;
void f0() override; // 占用 A::f0 的槽位
}; 分派过程:a_ptr->f0() 编译为「取对象头部的 vptr → 按固定偏移取函数地址 → 间接调用」。B::f0 覆盖了槽位 0,于是实际类型为 B 的对象在槽位 0 拿到的是 B::f0。派生类新增的虚函数追加在表尾,不影响基类部分的槽位对齐。
单继承下整个对象只需一个 vptr,且基类子对象与派生类对象起始地址相同——基类指针与派生类指针之间的转换只是重新解释同一个地址。
虚表不只放函数地址
完整的 vtable 在函数指针区的前面(负偏移处)还有两个实体:
vtable for B
┌────────────────┐
vptr ──────► │ B::f0() │ 槽位 0
├────────────────┤
│ A::f1() │ 槽位 1
└────────────────┘
-8 ► │ RTTI for B │ 指向 type_info
-16 ► │ offset_to_top │ 见下文 - RTTI 槽:指向该类的
type_info对象,支撑typeid与dynamic_cast; - offset_to_top:当前这个虚表起始位置到"最派生对象"起始地址的字节数。单继承下恒为 0,多继承下非主基类的虚表里会出现非零值。
多继承:主基类与 Thunk
struct C : A, B 时,A 和 B 的虚函数没有先后关系,各自的槽位偏移都从 0 开始,不可能挤进同一张表。Itanium ABI 的做法:
- 选一个基类作主基类(primary base,通常是有虚函数的第一个基类),它的 vptr 放在对象头部,C 自己的虚函数并入它的表;
- 其余每个基类子对象各带一个 vptr,指向 vtable 中一段独立的"子虚表",格式与完整虚表一致(有自己的 offset_to_top 与 RTTI);
- C 重写 B 的虚函数时,B 子虚表对应槽位放的不是
C::f1的真实地址,而是一个 Thunk:一小段跳板代码,先把 this 指针从 B 子对象位置调整回 C 对象起始位置,再跳到C::f1。
struct C : A, B 布局示意
object vtable for C
0 ├─ A 子对象 ─────► A 段(C::f0 …)
16 ├─ B 子对象 ─────► B 段(offset_to_top = -16, Thunk C::f1 …)
28 └─ C 成员 B* pb = &c; pb->f1(); 拿到的 this 指向 c + 16,Thunk 负责把它减 16 再进入真正的 C::f1。这就是为什么 dynamic_cast 与跨基类转换需要运行时信息:同一对象里"看出去"的基准点随使用的基类不同而不同,offset_to_top 记录了回到完整对象的距离。
构造与析构:vptr 的逐级切换
构造 C 时依次执行 A() → B() → C(),每个阶段编译器都会把(对应子对象的)vptr 设置为该类自己的虚表;析构按相反顺序逐级回退。这解释了构造/析构期间虚调用为何只命中"当前层"的版本——进入基类构造/析构时,vptr 已经不再指向最终派生类的表。这是语言标准规定的行为,细节与规避方式见《C++ 构造与析构中调用虚函数》一文。
虚继承:偏移量延迟到运行时
普通继承下,基类子对象在派生类中的偏移量编译期即可确定。虚继承打破了这一点:虚基类在继承体系中只保留一份拷贝,它相对于某个中间类的偏移量随最终类型变化。
A struct B : virtual A;
╱ ╲ struct C : virtual A;
B C struct D : B, C;
╲ ╱
D 实际类型为 B 时,A 子对象在偏移 16 处;实际类型为 D 时(B、C 各占一段之后才放 A),A 相对 B 子对象偏移 32。于是 B 的虚表需要补充两个实体:
- vbase_offset:从当前类子对象到虚基类子对象的偏移量,让运行时能定位虚基类;
- vcall_offset:经由虚基类指针调用虚函数时,对 this 指针做整体调整所需的偏移量(不同函数所属的类不同,调整量可能不同,因此逐函数记录)。
D 的对象中共有多组 vptr(B 段、C 段、A 段各自的),指向虚表的不同位置。
VTT:构造析构期的"虚表配送表"
虚继承还带来一个构造期问题:构造 D 内的 B 子对象时,若使用 B 独立成类时的那张虚表,其中的 vbase_offset 是按"B 是最派生类"算的(16),与它在 D 中的真实情况(32)不符。解决办法是编译器为 D 额外生成构造期专用虚表(B-in-D、C-in-D),并把所有这些虚表的入口集中成一张"虚表的表"——VTT(Virtual Table Table)。D 的构造/析构函数调用基类构造/析构时,向其传递 VTT 中对应的入口,使每个阶段都拿到与当前布局匹配的虚表。
只有当类的父类链上存在虚继承时,编译器才会生成 VTT;普通继承的对象不需要这套机制。
小结
- 虚调用 = 经对象头部 vptr 取槽位函数地址的间接调用;派生类覆盖占用同一槽位;
- vtable 除函数指针外还带 RTTI 与 offset_to_top,支撑
typeid/dynamic_cast与 this 指针调整; - 多继承下非主基类的虚表槽位放 Thunk,负责把 this 调整回完整对象再进入真实函数;
- 构造/析构期间 vptr 逐级切换,这是"构造析构中调用虚函数多态失效"的直接原因;
- 虚继承使基类偏移量延迟到运行期确定,靠 vbase_offset 与 vcall_offset 定位,构造期由 VTT 传递匹配布局的虚表。
以上布局是 Itanium C++ ABI 的约定(GCC、Clang 在类 Unix 平台采用),MSVC 的实现细节不同,但"表格驱动 + this 调整"的思路一致。
参考
- Itanium C++ ABI — https://itanium-cxx-abi.github.io/cxx-abi/abi.html(vtable layout、VTT)
- 知乎:C++ 中虚函数、虚继承内存模型 — https://zhuanlan.zhihu.com/p/41309205(本文结构参考之一)
- 相关阅读:C++ 构造与析构中调用虚函数:多态失效与纯虚调用崩溃